Express 에러 미들웨어가 네 개의 인자를 받는 이유

Express 에러 미들웨어가 네 개의 인자를 받는 이유

한눈에 보기

Express는 middleware의 시그니처를 보고 일반 흐름 (req, res, next)과 오류 흐름 (err, req, res, next)을 구분한다. error handler에서 next를 사용하지 않아도 네 번째 인자를 선언해야 한다. 오류 처리기는 내부 예외를 HTTP status와 안정적인 error code로 변환하고, stack·SQL·비밀값을 응답에서 숨기며, 이미 header가 전송됐다면 기본 처리기로 넘겨야 한다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

문제가 되는 상황

다음 함수를 error handler로 만들었다고 하자.

function errorHandler(
  error: unknown,
  req: Request,
  res: Response,
) {
  res.status(500).json({
    code: "INTERNAL_ERROR",
  });
}

app.use(errorHandler);

개발자는 next를 사용하지 않으니 세 인자만 선언했다. 하지만 Express는 이 함수를 (req, res, next) 일반 middleware로 해석할 수 있다. 실제 error flow에서는 호출되지 않고, 정상 요청에서 error 자리에 req 객체가 들어가는 이상한 동작이 생긴다.

Express error handler는 반드시 네 인자 시그니처를 유지한다.

function errorHandler(
  error: unknown,
  req: Request,
  res: Response,
  next: NextFunction,
) {
  // next를 직접 사용하지 않아도 네 번째 인자를 선언한다.
  res.status(500).json({
    code: "INTERNAL_ERROR",
  });
}

이 규칙은 TypeScript 타입만의 문제가 아니라 Express router가 middleware 종류를 분기하는 runtime 계약이다.

Express에는 일반 흐름과 오류 흐름이 있다

정상 요청은 일반 middleware와 route handler를 등록 순서대로 지난다.

flowchart LR
    A[Request] --> B[requestContext]
    B --> C[authenticate]
    C --> D[route handler]
    D --> E[Response]

next(error)가 호출되거나 Express가 처리 가능한 throw/rejection을 만나면 남은 일반 middleware를 건너뛰고 뒤에 등록된 error-handling middleware를 찾는다.

flowchart LR
    A[Request] --> B[route handler]
    B -->|throw or next error| C[일반 middleware 건너뜀]
    C --> D[errorHandler]
    D --> E[Error Response]

Express 공식 문서 기준으로 next()'route' 또는 'router'를 제외한 값을 전달하면 error로 간주한다.

function loadUser(req: Request, res: Response, next: NextFunction) {
  userRepository.findById(req.params.userId, (error, user) => {
    if (error) {
      return next(error);
    }

    if (!user) {
      return next(new UserNotFoundError(req.params.userId));
    }

    req.user = user;
    return next();
  });
}

정상 next()next(error)는 같은 함수처럼 보이지만 다음 탐색 대상이 다르다.

네 개의 인자가 식별 계약이다

일반 middleware:

type NormalMiddleware = (
  req: Request,
  res: Response,
  next: NextFunction,
) => unknown;

오류 middleware:

type ErrorMiddleware = (
  error: unknown,
  req: Request,
  res: Response,
  next: NextFunction,
) => unknown;

Express는 네 인자의 error-handling signature를 요구한다. 함수 본문에서 next를 쓰지 않는다고 제거해서는 안 된다.

lint가 unused parameter를 경고하면 이름을 _next로 둘 수 있다.

function errorHandler(
  error: unknown,
  req: Request,
  res: Response,
  _next: NextFunction,
) {
  // ...
}

다만 res.headersSent일 때 실제로 next가 필요하므로 완성된 handler에서는 사용하는 편이 일반적이다.

function errorHandler(error, req, res, next) {
  if (res.headersSent) {
    return next(error);
  }

  return res.status(500).json({ code: "INTERNAL_ERROR" });
}
wrapper가 함수 길이를 바꾸는지 확인한다

일부 커스텀 wrapper나 decorator가 error handler를 감싸며 일반 세 인자 함수로 노출하면 Express가 잘못 분류할 수 있다. 실제 등록되는 최종 함수의 시그니처를 테스트한다.

동기 Throw와 next error

동기 route handler에서 throw한 오류는 Express가 error flow로 전달한다.

app.get("/api/products/:id", (req, res) => {
  const id = Number(req.params.id);

  if (!Number.isInteger(id)) {
    throw new InvalidProductIdError(req.params.id);
  }

  res.json({ id });
});

callback 기반 비동기 API에서는 callback 안의 error를 next(error)로 전달한다.

app.get("/api/files/:name", (req, res, next) => {
  fs.readFile(resolveSafePath(req.params.name), (error, data) => {
    if (error) {
      return next(error);
    }

    return res.type("application/octet-stream").send(data);
  });
});

callback 안에서 단순 throw하면 Express가 설정한 동기 call stack 밖에서 발생해 error handler로 연결되지 않을 수 있다.

// 피해야 할 예
fs.readFile(filePath, (error, data) => {
  if (error) {
    throw error;
  }
  res.send(data);
});

Promise chain은 반드시 반환하거나 catch에서 next로 넘긴다.

app.get("/api/products/:id", (req, res, next) => {
  return productService
    .getById(req.params.id)
    .then((product) => res.json({ product }))
    .catch(next);
});

catch(next)는 rejected reason을 그대로 전달한다. library가 Error가 아닌 문자열이나 객체를 reject할 수 있다면 error normalizer에서 안전하게 다룬다.

Express 4와 5의 Async Error 차이

Express 5 공식 문서는 Promise를 반환하는 middleware와 handler가 reject하거나 throw하면 router가 자동으로 next(value)를 호출한다고 설명한다.

// Express 5
app.get("/api/orders/:id", async (req, res) => {
  const order = await orderService.getById(req.params.id);
  res.json({ order });
});

orderService.getById()가 reject하면 error middleware로 전달된다.

Express 4에서는 async function의 rejected Promise가 자동 전달되지 않는 경우를 위해 wrapper를 사용한다.

const asyncHandler =
  <P, ResBody, ReqBody, ReqQuery>(
    handler: RequestHandler<P, ResBody, ReqBody, ReqQuery>,
  ): RequestHandler<P, ResBody, ReqBody, ReqQuery> =>
  (req, res, next) =>
    Promise.resolve(handler(req, res, next)).catch(next);
router.get(
  "/orders/:id",
  asyncHandler(async (req, res) => {
    const order = await orderService.getById(req.params.id);
    res.json({ order });
  }),
);

프로젝트가 Express 5로 올라갔는데 기존 wrapper가 error를 중복 전달하지 않는지 확인한다. wrapper 구현이 Promise를 return하지 않거나 catch 후 다시 throw하면 예상치 못한 흐름이 생길 수 있다.

상황 Express 4 Express 5
동기 throw error flow error flow
next(error) error flow error flow
반환된 Promise reject wrapper/명시 catch가 필요한 일반적 패턴 자동 next
callback 내부 error next(error) 필요 next(error) 필요
fire-and-forget Promise reject 자동 연결 안 됨 반환하지 않으면 자동 연결되지 않음

정확한 동작은 Express 5 Error Handling 공식 문서와 사용하는 major version을 기준으로 확인한다.

오류를 HTTP 응답으로 변환하는 계층

service와 repository가 Express의 res 객체를 직접 다루면 도메인 오류와 HTTP 표현이 결합된다.

// 피하고 싶은 결합
async function getOrder(req: Request, res: Response) {
  const order = await repository.findById(req.params.id);

  if (!order) {
    return res.status(404).json({ message: "not found" });
  }
}

도메인 계층은 의미 있는 오류를 던지고 error middleware가 HTTP 계약으로 변환한다.

class AppError extends Error {
  constructor(
    readonly code: string,
    readonly status: number,
    message: string,
    readonly details?: Record<string, unknown>,
  ) {
    super(message);
  }
}

class OrderNotFoundError extends AppError {
  constructor(orderId: string) {
    super(
      "ORDER_NOT_FOUND",
      404,
      "주문을 찾을 수 없습니다.",
      { orderId },
    );
  }
}

service:

async function getOrder(orderId: string): Promise<Order> {
  const order = await orderRepository.findById(orderId);

  if (!order) {
    throw new OrderNotFoundError(orderId);
  }

  return order;
}

error mapper:

function toHttpError(error: unknown): HttpErrorResponse {
  if (error instanceof AppError) {
    return {
      status: error.status,
      body: {
        code: error.code,
        message: error.message,
      },
    };
  }

  return {
    status: 500,
    body: {
      code: "INTERNAL_ERROR",
      message: "요청을 처리하지 못했습니다.",
    },
  };
}

DB unique violation, validation library error와 upstream timeout도 adapter에서 AppError로 변환할 수 있다. error middleware 한 함수에 모든 driver별 분기와 업무 로직을 넣지 않는다.

예상 가능한 오류와 알 수 없는 오류를 구분한다

예상 가능한 operational/domain error:

입력 validation 실패
인증·권한 부족
resource 없음
중복 요청
재고 부족
upstream timeout
rate limit

알 수 없는 programmer/infrastructure error:

undefined property 접근
불가능한 상태 invariant 위반
DB driver의 미분류 오류
serialization bug
메모리·파일 시스템 문제

예상 오류는 client가 행동을 바꿀 수 있는 안정적인 status와 code를 반환한다.

{
  "code": "OUT_OF_STOCK",
  "message": "재고가 부족합니다.",
  "requestId": "sample-request-id"
}

알 수 없는 오류는 내부 메시지를 숨긴 500을 반환하고 높은 심각도로 기록한다.

{
  "code": "INTERNAL_ERROR",
  "message": "요청을 처리하지 못했습니다.",
  "requestId": "sample-request-id"
}
error.message를 그대로 client에 반환하지 않는다

SQL, 파일 경로, 내부 host, token과 개인정보가 포함될 수 있다. client 메시지는 명시적으로 허용된 AppError만 사용한다.

오류 응답 계약을 안정적으로 만든다

HTTP status만으로 client가 업무 오류를 안정적으로 구분하기 어렵다. 같은 409라도 이메일 중복, version conflict, idempotency conflict가 다르다.

type ErrorBody = {
  code: string;
  message: string;
  requestId: string;
  fields?: Record<string, string>;
};

validation 응답:

{
  "code": "VALIDATION_FAILED",
  "message": "입력값을 확인해 주세요.",
  "requestId": "sample-request-id",
  "fields": {
    "email": "올바른 이메일 형식이 아닙니다."
  }
}

error code는 로그 문구가 아니라 public API 계약이다. 이름을 바꾸면 client와 문서를 함께 변경해야 한다. 영어 code와 locale별 message를 분리할 수도 있다.

details 전체를 자동 노출하지 않는다. 내부 orderId는 괜찮아 보여도 tenant ID, provider response가 들어올 수 있다. 공개 가능한 field만 mapper가 선택한다.

function mapValidationError(error: ValidationError): ErrorBody {
  return {
    code: "VALIDATION_FAILED",
    message: "입력값을 확인해 주세요.",
    requestId: currentRequestId(),
    fields: publicValidationFields(error),
  };
}

headersSent를 확인해야 하는 이유

streaming 중 오류가 발생하면 response header와 일부 body가 이미 전송됐을 수 있다.

app.get("/download", async (req, res, next) => {
  res.setHeader("content-type", "application/octet-stream");

  const stream = createFileStream();
  stream.on("error", next);
  stream.pipe(res);
});

일부 bytes가 전송된 뒤 error middleware가 새 JSON 500 응답을 보내려 하면 header를 바꿀 수 없다.

function errorHandler(error, req, res, next) {
  if (res.headersSent) {
    return next(error);
  }

  const mapped = toHttpError(error);
  return res.status(mapped.status).json({
    ...mapped.body,
    requestId: req.context.requestId,
  });
}

Express 기본 error handler는 header가 이미 전송된 상황에서 connection을 닫는 등 남은 처리를 할 수 있다. custom handler는 next(error)로 위임한다.

streaming endpoint의 오류 계약은 일반 JSON API와 다르다

client는 중간에 끊긴 download를 감지하고 retry·checksum을 사용해야 할 수 있다. 서버가 뒤늦게 JSON error body로 바꿀 수 없다.

Error Logging은 한 번만 책임진다

repository, service, route, error middleware가 같은 오류를 모두 로그하면 하나의 장애가 네 줄로 중복된다.

repository ERROR query failed
service ERROR get order failed
route ERROR request failed
errorHandler ERROR internal error

처리하지 않고 위로 전달하는 계층은 context만 추가해 throw하고 최종 request boundary에서 한 번 구조화 로그를 남기는 전략이 좋다.

function logRequestError(
  error: unknown,
  req: Request,
  mapped: HttpErrorResponse,
) {
  const level = mapped.status >= 500 ? "error" : "info";

  logger[level]({
    event: "http_request_failed",
    requestId: req.context.requestId,
    method: req.method,
    route: req.route?.path,
    status: mapped.status,
    errorCode: mapped.body.code,
    error: serializeSafeError(error),
  });
}

4xx를 모두 error level로 기록하면 validation noise가 실제 500을 묻는다. 예상 4xx는 info/warn과 metric, 알 수 없는 5xx는 error로 분리한다.

비밀번호, Authorization header, cookie, request body 전체와 DB connection string을 로그에 넣지 않는다. allow-list field만 남긴다.

error cause chain이 있다면 내부 로그에는 보존할 수 있다.

throw new ProductLoadError(productId, { cause: databaseError });

client에는 가장 바깥 public code만 노출한다.

next를 두 번 호출하는 문제

callback과 Promise를 섞으면 error를 두 번 전달할 수 있다.

app.get("/example", (req, res, next) => {
  doWork((error, result) => {
    if (error) {
      next(error);
    }

    // return이 없어 계속 실행
    res.json(result);
  });
});

error 뒤 return한다.

if (error) {
  return next(error);
}

catch에서 next를 호출한 뒤 다시 throw하는 것도 중복 전달 가능성이 있다.

try {
  await operation();
} catch (error) {
  next(error);
  throw error;
}

둘 중 하나만 선택한다.

catch (error) {
  return next(error);
}

Express 기본 handler가 같은 error를 두 번 받으면 response가 이미 시작된 상황처럼 처리될 수 있다. wrapper, error boundary와 route에서 누가 전달을 책임지는지 통일한다.

Validation과 404를 모두 예외로 만들 필요는 없다

모든 비정상 응답을 throw로 통일할 수도 있지만, middleware가 직접 응답하는 편이 더 명확한 경우도 있다.

function validateCreateOrder(req, res, next) {
  const result = createOrderSchema.safeParse(req.body);

  if (!result.success) {
    return res.status(400).json({
      code: "VALIDATION_FAILED",
      fields: toPublicFields(result.error),
      requestId: req.context.requestId,
    });
  }

  req.validatedBody = result.data;
  return next();
}

반면 service 깊은 곳에서 발견한 업무 오류는 throw가 호출 계층을 단순하게 만든다.

최종 unmatched route도 일반 middleware에서 404를 바로 보낼 수 있다.

app.use((req, res) => {
  res.status(404).json({
    code: "ROUTE_NOT_FOUND",
    requestId: req.context.requestId,
  });
});

혹은 next(new RouteNotFoundError())로 error mapper를 재사용할 수 있다. 팀이 logging과 응답 형식을 일관되게 유지할 수 있는 쪽을 선택한다.

핵심은 throw를 제어 흐름의 마법으로 쓰는 것이 아니라 어떤 layer가 response를 소유하는지 명확히 하는 것이다.

Process를 종료해야 하는 오류와 요청 오류

error middleware가 모든 오류를 500으로 바꾸고 프로세스를 계속 실행해도 되는 것은 아니다.

요청 범위 오류:

잘못된 입력
DB timeout
외부 API 실패
특정 row constraint 위반

요청을 실패시키고 다음 요청을 처리할 수 있다.

프로세스 상태를 신뢰하기 어려운 오류:

uncaught exception
unhandled rejection 정책 위반
필수 background worker crash
불가능한 global invariant 파괴
메모리 고갈 징후

이 경우 로그와 telemetry를 flush하고 readiness를 내린 뒤 process manager가 새 인스턴스를 시작하게 하는 정책을 검토한다. 모든 uncaught error를 붙잡고 무조건 계속 실행하면 손상된 상태로 요청을 받을 수 있다.

process.on("uncaughtException", (error) => {
  logger.fatal({ error: serializeSafeError(error) }, "uncaught exception");
  void shutdownManager.initiate("uncaughtException");
});

하지만 process event handler에서 복잡한 비동기 복구를 무한정 기다리지 않고 강제 timeout을 둔다. 이 흐름은 Node.js 서버의 Graceful Shutdown 구현하기와 연결된다.

요청 error handler와 process error handler를 구분한다

Express error middleware는 이미 Express가 포착해 현재 요청에 연결한 오류를 처리한다. process-level uncaught error는 다른 안전 경계다.

통합 테스트로 오류 경로 고정하기

도메인 오류 매핑

it("없는 주문은 안정적인 404 code를 반환한다", async () => {
  orderService.getById.mockRejectedValue(
    new OrderNotFoundError("order-42"),
  );

  const response = await request(app).get("/api/orders/order-42");

  expect(response.status).toBe(404);
  expect(response.body).toMatchObject({
    code: "ORDER_NOT_FOUND",
  });
  expect(response.body.stack).toBeUndefined();
});

알 수 없는 오류 정보 숨김

it("내부 오류 메시지를 client에 노출하지 않는다", async () => {
  orderService.getById.mockRejectedValue(
    new Error("password=sample-secret database host=internal"),
  );

  const response = await request(app).get("/api/orders/order-42");

  expect(response.status).toBe(500);
  expect(response.body.code).toBe("INTERNAL_ERROR");
  expect(JSON.stringify(response.body)).not.toContain("sample-secret");
});

Express 5 async rejection

it("async handler rejection이 error middleware에 도달한다", async () => {
  productService.getById.mockRejectedValue(new Error("sample failure"));

  const response = await request(app).get("/api/products/42");

  expect(response.status).toBe(500);
  expect(response.body.requestId).toBeDefined();
});

네 인자 handler 등록

HTTP 통합 테스트가 500 응답과 구조화 log까지 검증하면 signature 누락도 발견한다.

headers sent

stream 오류는 별도 테스트 server에서 일부 body 이후 connection close를 검증한다. 일반 JSON body를 기대하지 않는다.

운영 metric도 함께 본다.

http_requests_total{status, route}
http_errors_total{error_code, route}
unhandled_error_total
response_aborted_total

route parameter의 실제 값을 metric label로 쓰면 cardinality가 폭증하므로 template route와 안정적인 error code를 사용한다.

결론

Express error middleware가 네 개의 인자를 받는 이유는 runtime이 일반 request 흐름과 error 흐름을 구분하는 시그니처이기 때문이다. next를 사용하지 않아도 (err, req, res, next)를 유지해야 하며 모든 route와 일반 middleware 뒤에 등록한다.

동기 throw와 next(error)는 error flow로 전달되고 Express 5는 반환된 Promise rejection도 자동 전달하지만 Express 4와 callback 기반 코드는 별도 처리가 필요하다. error boundary에서는 알려진 도메인 오류만 안정적인 HTTP status·code·message로 공개하고 알 수 없는 오류의 내부 정보는 숨긴다. res.headersSent이면 기본 handler로 넘기고, 같은 오류를 여러 계층에서 중복 logging하거나 next를 두 번 호출하지 않는다. 최종적으로 도메인 오류, async rejection, 내부 정보 마스킹과 streaming 실패를 실제 HTTP 통합 테스트로 검증해야 한다.

관련 노트

참고 자료